In the previous article, Dmitry described ways to reduce Java application startup time. Here, I offer you to try out one of them, Class Data Sharing (CDS). It provides a good improvement in startup, not as drastic as with CRaC or Native Image, but it doesn’t necessitate code refactoring. And since Spring Boot 3.3 supports CDS, and BellSoft provides binaries and ready container images with Liberica JDK and CDS, starting with this feature couldn’t be easier!
Table of Contents
What is Class Data Sharing (CDS)
Class Data Sharing (CDS) is a JVM feature aimed at reducing the startup and memory footprint of multiple JVM instances running on the same host. The feature loads a default set of classes from the system Java Archive (JAR) file and stores this data in a file, which is then available as read-only metadata to multiple JVM processes.
CDS was first introduced in JDK 5 and received numerous enhancements since then. For instance, JEP 310 in OpenJDK 10 extended on the CDS feature and introduced Application Class Data Sharing (AppCDS), which enables loading application classes into the archive as well. AppCDS is further improved with JEP 350 in OpenJDK 13 that allows for including all loaded application classes and library classes not present in the default CDS archive upon the application exit. Dynamic CDS eliminated the need to do trial runs to create a class list for the application.
So when we say below that we create and use a CDS archive with a Spring Boot application, we actually work with AppCDS.
How to use CDS with Spring Boot
Spring Boot 3.3 offers a convenient way to create a CDS archive. All you need is to specify two options upon application start:
-XX:ArchiveClassesAtExit=application.jsa
to create an archive of classes and-Dspring.context.exit=onRefresh
to start and immediately exit the application after non-lazy beans have been instantiated andInitializingBean#afterPropertiesSet
callbacks have been invoked.
Note that to run the application with the CDS archive, you must use the same JVM utilized for creating the archive. In addition, the classpath must be the same. If you don’t specify the classpath option, it will consist of the current directory.
Create and use the CDS archive on a local machine
For this tutorial, I will use the Spring Petclinic demo application. You can use any other project, just make sure that Spring Boot version is 3.3. As for the Java runtime, we will use Liberica JDK recommended by the Spring team. Download Liberica JDK 21 for your platform directly from the website or choose any other installation method described there.
Alright, we’re all set. Let’s first create a jar file of our application with:
mvn -Dmaven.test.skip=true clean package
But we are not going to use an executable jar. Running CDS directly on an executable jar in production is not recommended because running it introduces certain overhead. So, we will take advantage of an exploded jar, which is more efficient and is recommended to be used with CDS by Spring. The command for creating the CDS archive is as follows:
java -Djarmode=tools -jar target/spring-petclinic-3.3.0-SNAPSHOT.jar extract
java -XX:ArchiveClassesAtExit=./application.jsa -Dspring.context.exit=onRefresh -jar spring-petclinic-3.3.0-SNAPSHOT/spring-petclinic-3.3.0-SNAPSHOT.jar
Note that if you use another JDK, you may get a warning similar to
-XX:ArchiveClassesAtExit is unsupported when the base CDS archive is not loaded. Run with -Xlog:cds for more info.
This means that you will have to create a base image first with -Xshare:dump
.
We can now use the created archive to launch our application:
java -XX:SharedArchiveFile=application.jsa -jar spring-petclinic-3.3.0-SNAPSHOT/spring-petclinic-3.3.0-SNAPSHOT.jar
That’s it, two commands only and your application uses the CDS archive!
Peeking under the hood of CDS
In the previous section, we let Spring Boot and JDK do their magic. But I want to lift the veil over the process of creating the CDS archive and see how many and what classes get dumped there.
That being said, modify the command for creating the archive and add logging to analyze the results of CDS implementation later:
java -XX:ArchiveClassesAtExit=application.jsa -Xlog:cds=debug:file=log/cds.log -Dspring.context.exit=onRefresh -jar spring-petclinic-3.3.0-SNAPSHOT/spring-petclinic-3.3.0-SNAPSHOT.jar
The -Xlog:cds=debug:file=log/cds.log
option will log the process of CDS archive creation.
Let’s modify the command for launching the application with the archive in a similar fashion. We can add logging to see whether the archive was actually used upon application startup (-Xlog:class+path=debug
) and count the number of loaded classes (-Xlog:class+load=info
):
java -XX:SharedArchiveFile=application.jsa -Xlog:class+load=info:file=log/class-load.log -Xlog:class+path=debug:file=log/class-path.log -jar spring-petclinic-3.3.0-SNAPSHOT/spring-petclinic-3.3.0-SNAPSHOT.jar
Great! After the application has started, exit it and check the logs.
The cds.log file lists the information about the skipped classes, i.e., classes that didn’t make it into the archive for some reason. For instance:
[4.118s][warning][cds] Skipping net/bytebuddy/dynamic/ClassFileLocator$Resolution$Explicit: Old class has been linked
[4.118s][debug ][cds] Skipping org/springframework/boot/autoconfigure/web/embedded/TomcatWebServerFactoryCustomizer$$Lambda+0x000000d0014722b0: Hidden class
[4.118s][warning][cds] Skipping net/bytebuddy/dynamic/DynamicType$Builder$MethodDefinition$ParameterDefinition$Simple: interface net/bytebuddy/dynamic/DynamicType$Builder$MethodDefinition$ExceptionDefinition is excluded
[4.118s][debug ][cds] Skipping org/springframework/data/jpa/repository/query/JpaQueryParserSupport$ParseState$$Lambda+0x000000d001966cf8: Hidden class
[4.118s][debug ][cds] Skipping java/lang/invoke/LambdaForm$DMH+0x000000d001b80c00: Hidden class
[4.118s][debug ][cds] Skipping java/lang/invoke/LambdaForm$DMH+0x000000d001015c00: Hidden class
[4.118s][debug ][cds] Skipping java/lang/invoke/LambdaForm$DMH+0x000000d001b4a000: Hidden class
These are
- Hidden classes, which were introduced in JDK 15 with JEP 371 and represent classes that cannot be used directly by the bytecode of other classes. These classes are not suitable for the CDS archive due to their dynamic nature;
- Old classes, which are classes from older Java versions. They can typically be found in legacy API libraries;
- Child classes of other excluded classes.
The class-path.log file tells us that the archive was indeed used:
[0.005s][info][class,path] bootstrap loader class path=/Library/Java/JavaVirtualMachines/liberica-jdk-21.jdk/Contents/Home/lib/modules
[0.012s][info][class,path] Expecting BOOT path=/Library/Java/JavaVirtualMachines/liberica-jdk-21.jdk/Contents/Home/lib/modules
[0.012s][info][class,path] Expecting -Djava.class.path=
[0.012s][info][class,path] checking shared classpath entry: /Library/Java/JavaVirtualMachines/liberica-jdk-21.jdk/Contents/Home/lib/modules
[0.012s][info][class,path] ok
[0.125s][info][class,path] Expecting BOOT path=/Library/Java/JavaVirtualMachines/liberica-jdk-21.jdk/Contents/Home/lib/modules
[0.125s][info][class,path] Expecting -Djava.class.path=spring-petclinic-3.3.0-SNAPSHOT/spring-petclinic-3.3.0-SNAPSHOT.jar
[0.125s][info][class,path] checking shared classpath entry: /Library/Java/JavaVirtualMachines/liberica-jdk-21.jdk/Contents/Home/lib/modules
[0.125s][info][class,path] ok
[0.125s][info][class,path] checking shared classpath entry: spring-petclinic-3.3.0-SNAPSHOT/spring-petclinic-3.3.0-SNAPSHOT.jar
[0.125s][info][class,path] ok
How about the number of loaded classes?
Run the following command to see how many classes were loaded in total:
cat log/class-load.log | wc -l
16242
The same file contains data on files that were loaded from the shared archive. Run the following command to get the number:
grep -o 'source: shared' -c log/class-load.log
14391
As you can see, about 88% of classes got into the archive.
What about the startup? The mean startup time of the application running without the archive was 3.3 seconds. In turn, the mean startup with the preloaded CDS archive was 1.9 seconds, which means that in this case, CDS yields 42% faster startup. Already great, but we can do even better with Spring AOT!
Create the CDS archive with Spring AOT enabled
Spring Ahead-of-time optimizations (AOT) are aimed primarily at leveraging the power of GraalVM Natime Image for Spring applications, but they can als help with startup reduction when using the standard JVM. I thought I should give it a try after reading CDS with Spring Framework 6.1 by Sébastien Deleuze. And I was amazed to see for myself how without extensive huffing and puffing with the configs you can get impressive performance boost!
Right, to the task at hand. Let’s first enable AOT processing in the pom.xml:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>process-aot</id>
<goals>
<goal>process-aot</goal>
</goals>
</execution>
</executions>
</plugin>
You can also enable AOT compilation without modifying the pom.xml manually. To do that, you need to run:
mvn clean compile spring-boot:process-aot package
Now, modify the command for generating the CDS archive in the following way:
mvn -Dmaven.test.skip=true clean package
java -Djarmode=tools -jar target/spring-petclinic-3.3.0-SNAPSHOT.jar extract
java -Dspring.aot.enabled=true -XX:ArchiveClassesAtExit=./application.jsa -Dspring.context.exit=onRefresh -jar spring-petclinic-3.3.0-SNAPSHOT/spring-petclinic-3.3.0-SNAPSHOT.jar
After the archive has been created, run the application with:
java -Dspring.aot.enabled=true -XX:SharedArchiveFile=application.jsa -jar spring-petclinic-3.3.0-SNAPSHOT/spring-petclinic-3.3.0-SNAPSHOT.jar
In my case, Spring Petclinic started in 1.5 seconds, reflecting 54% startup time reduction!
Create the CDS archive using a container with CDS
In the tutorial below, we will make use of Docker multi-stage builds to create a CDS archive of a Spring Boot application and use it in a Docker container. We will use a Liberica Runtime Container with CDS (tagged with a cds
tag).
Liberica Runtime Container is based on Liberica JDK, a Java runtime recommended by Spring, and Alpaquita Linux, a lightweight Linux distro tailor-made for Java. A natural choice for our experiment, I dare say.
If you want to compare startup times, you can first build a standard container image using the following Dockerfile:
FROM bellsoft/liberica-runtime-container:jdk-21-stream-musl as builder
RUN apk add wget
WORKDIR /home/app
ADD spring-petclinic-main /home/app/spring-petclinic-main
RUN cd spring-petclinic-main && ./mvnw -Dmaven.test.skip=true clean package
FROM bellsoft/liberica-runtime-container:jdk-21-stream-musl
WORKDIR /home/app
COPY /home/app/spring-petclinic-main/target/*.jar petclinic.jar
CMD ["java", "-jar", "petclinic.jar"]
Here, we create a fat jar and run it the usual way.
Build the image with:
docker build -t petclinic-standard .
And then run it with:
docker run petclinic-standard
In my case, it took 6.1 seconds for the containerized application to start.
Now, let’s make use of CDS and AOT.
To create the archive in a Docker container, you need the following Dockerfile:
FROM bellsoft/liberica-runtime-container:jdk-21-crac-cds-musl as builder
WORKDIR /home/app
ADD spring-petclinic /home/app/spring-petclinic-main
RUN cd spring-petclinic-main && ./mvnw -Dmaven.test.skip=true clean package
FROM bellsoft/liberica-runtime-container:jdk-21-cds-slim-musl as optimizer
WORKDIR /app
COPY /home/app/spring-petclinic-main/target/*.jar petclinic.jar
RUN java -Djarmode=tools -jar petclinic.jar extract --layers --launcher
FROM bellsoft/liberica-runtime-container:jdk-21-cds-slim-musl
ENTRYPOINT ["java", "-Dspring.aot.enabled=true", "-XX:SharedArchiveFile=application.jsa", "org.springframework.boot.loader.launch.JarLauncher"]
COPY /app/petclinic/dependencies/ ./
COPY /app/petclinic/spring-boot-loader/ ./
COPY /app/petclinic/snapshot-dependencies/ ./
COPY /app/petclinic/application/ ./
RUN java -Dspring.aot.enabled=true -XX:ArchiveClassesAtExit=./application.jsa -Dspring.context.exit=onRefresh org.springframework.boot.loader.launch.JarLauncher
Here, we have tree build stages, each of which uses the base image with Liberica JDK and CDS.
During the first stage, we install wget into the image, which is required for Maven Wrapper to download Maven. Then, we build the usual fat jar file.
During the second stage, we make use of Spring Boot -Djarmode=tools
to extract layers from our application. Running a layered jar in a containerized environment reduces overhead. In addition, the layering gives the developers more fine-grained control over the image updates. The most frequently updated layers are placed on top of the image, so in most cases, Docker only needs to update these top layers and pull other layers from the cache.
During the third stage, we transfer extracted layers into a fresh image. We also run the application with the options required for creating a CDS archive. The entrypoint contains the -XX:SharedArchiveFile=application.jsa
option for using the archive. We use a special JarLauncher class because it knows how to work with exploded jars.
Run the standard command for building a Docker image:
docker build -t cdsimage .
After that, you can run the container image of the application the usual way:
docker run cdsimage
|\ _,,,--,,_
/,`.-'`' ._ \-;;,_
_______ __|,4- ) )_ .;.(__`'-'__ ___ __ _ ___ _______
| | '---''(_/._)-'(_\_) | | | | | | | | |
| _ | ___|_ _| | | | | |_| | | | __ _ _
| |_| | |___ | | | | | | | | | | \ \ \ \
| ___| ___| | | | _| |___| | _ | | _| \ \ \ \
| | | |___ | | | |_| | | | | | | |_ ) ) ) )
|___| |_______| |___| |_______|_______|___|_| |__|___|_______| / / / /
==================================================================/_/_/_/
:: Built with Spring Boot :: 3.3.0
2024-06-06T12:47:22.077Z INFO 1 --- [ main] o.s.s.petclinic.PetClinicApplication : Starting AOT-processed PetClinicApplication v3.3.0-SNAPSHOT using Java 21.0.3 with PID 1 (/BOOT-INF/classes started by root in /)
2024-06-06T12:47:22.079Z INFO 1 --- [ main] o.s.s.petclinic.PetClinicApplication : No active profile set, falling back to 1 default profile: "default"
2024-06-06T12:47:22.622Z INFO 1 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port 8080 (http)
2024-06-06T12:47:22.629Z INFO 1 --- [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2024-06-06T12:47:22.630Z INFO 1 --- [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/10.1.24]
2024-06-06T12:47:22.661Z INFO 1 --- [ main] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring embedded WebApplicationContext
2024-06-06T12:47:22.662Z INFO 1 --- [ main] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 580 ms
2024-06-06T12:47:22.906Z INFO 1 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2024-06-06T12:47:22.988Z INFO 1 --- [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:5c9bc24a-4ae6-4639-981e-6b04279a2e7a user=SA
2024-06-06T12:47:22.989Z INFO 1 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2024-06-06T12:47:23.098Z INFO 1 --- [ main] o.hibernate.jpa.internal.util.LogHelper : HHH000204: Processing PersistenceUnitInfo [name: default]
2024-06-06T12:47:23.120Z INFO 1 --- [ main] org.hibernate.Version : HHH000412: Hibernate ORM core version 6.5.2.Final
2024-06-06T12:47:23.136Z INFO 1 --- [ main] o.h.c.internal.RegionFactoryInitiator : HHH000026: Second-level cache disabled
2024-06-06T12:47:23.322Z INFO 1 --- [ main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2024-06-06T12:47:23.870Z INFO 1 --- [ main] o.h.e.t.j.p.i.JtaPlatformInitiator : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2024-06-06T12:47:23.871Z INFO 1 --- [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2024-06-06T12:47:24.072Z INFO 1 --- [ main] o.s.d.j.r.query.QueryEnhancerFactory : Hibernate is in classpath; If applicable, HQL parser will be used.
2024-06-06T12:47:24.980Z INFO 1 --- [ main] o.s.b.a.e.web.EndpointLinksResolver : Exposing 14 endpoints beneath base path '/actuator'
2024-06-06T12:47:25.038Z INFO 1 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
2024-06-06T12:47:25.041Z INFO 1 --- [ main] o.s.s.petclinic.PetClinicApplication : Started PetClinicApplication in 3.603 seconds (process running for 3.932)
As a result, we achieved startup reduction by 41%.
You can also run multiple containers based on this image. This way, they will share the same CDS archive, which may contribute to reduced memory consumption in the cloud.
Use CDS with buildpacks
Great news for Spring developers using buildpacks (and a good incentive to try them out for those who prefer traditional Dockerfiles): Paketo buildpacks support the creation of a CDS archive!
Make sure that you have pack installed, and Paketo Base builder is the default builder (more on containerizing Java applications with buildpacks here).
To make use of CDS and Spring AOT with buildpacks, run the following command:
pack build petclinic-cds-pack --env BP_JVM_VERSION=17 --env BP_SPRING_AOT_ENABLED=true --env BP_JVM_CDS_ENABLED=true
Summary
We discussed three ways of using CDS with Spring Boot. You can:
- Create the archive and run the app on the local machine,
- Use a Dockerfile,
- Use a buildpack.
To sum up, CDS is effortless to use and doesn’t require code refactoring. At the same time, it allows for a good startup improvement, up to 54%, depending on the configuration. This article analyzes only one application, so the results can vary depending on the program under evaluation.
Another claimed advantage of CDS is reduced memory footprint when the archive is shared among multiple VM instances. If you’d like to measure footprint reduction, there’s a great article by Volker Simonis that gives detailed instructions on running the experiment, gathering the statistics, and evaluating the data.